feat(interrupts): AG-UI interrupt lifecycle overhaul (ephemeral)#970
Merged
Conversation
…M-event catalog
- Add optional in-band `cursor` to StreamChunk + `cursor` input on chat().
- Add a *replayAndResume() seam: when a cursor is supplied and a ResumeSource
capability is provided, replay the persisted event tail; if the run is still
running and the adapter supports re-attach, continue live. No-op without a
resume source (a non-persisted run is unchanged).
- Move the generic LockStore + LocksCapability ('locks') into core so it is a
single shared token across the sandbox and persistence layers.
- Add ResumeSource capability/contract and a typed catalog of well-known CUSTOM
event names (file.changed, process.*, approval.*, artifact.*, sandbox.*).
…andbox bridge - @tanstack/ai-persistence: store contracts, withPersistence middleware, memoryPersistence, cursor utilities, approval controller, resume-source adapter, history projection. Fully optional; works with and without sandbox. - @tanstack/ai-persistence-sql: one SQL store impl behind a minimal SqlDriver (sqlite|postgres dialect) + versioned migrations + DDL. - Backends: -sqlite (node:sqlite/better-sqlite3), -postgres (pg), -cloudflare (D1, compile-verified), -drizzle and -prisma (BYO). - @tanstack/ai-sandbox-persistence: durable SQL-backed SandboxStore + withPersistenceBridge wiring durable store/locks into withSandbox (agent mode).
- Track the latest in-band cursor per active run; expose getResumeState(), resume(), and maybeAutoResume() with an `autoResume` opt-out (default on). - Pass `cursor` through the connection adapter's RunAgentInput payload so the server can replay a run. streamResponse reuses the original runId on resume.
…ip config - New docs/persistence/overview.md (+ nav entry, addedAt) with server + client + agent-mode usage. - New ai-core/persistence agent skill. - Changeset covering the feature; knip ignore for the optional pg peer dep.
# Conflicts: # docs/media/transcription.md
# Conflicts: # examples/ts-react-chat/src/routes/generations.image.tsx # examples/ts-react-chat/src/routes/generations.video.tsx # testing/e2e/src/routes/$provider/index.tsx
Client-tool interrupt paths emit AG-UI MESSAGES_SNAPSHOT from ModelMessages, which rebuild tool-call parts as input-complete without output and wipe the complete/output state the client already applied from TOOL_CALL_END/RESULT. Reconcile snapshots by folding tool-result content into matching tool-call parts (and prefer pre-snapshot complete state when richer).
MESSAGES_SNAPSHOT reconciliation now folds tool-result into the sibling tool-call; update the anchor test to match.
Avoid a mutated outer `changed` flag that control-flow analysis treats as always falsy; detect part identity changes instead.
Replace the prose-only "when an answer is wrong" section in multiple.md with a runnable component that renders item errors and root interruptErrors, gates buttons on status and resuming (not just canResolve), and demonstrates both recovery paths (clearResolution and retryInterrupts).
Add a "Consume the decision on the server" section to tool-approval.md: a server execute snippet reading the (possibly edited) input, and an honest account of the payload branches (reject payload becomes the tool result the model reads; approve payload is decision metadata, not passed to execute, use editedArgs for values the tool needs).
…noble/hashes The library no longer transforms a generic interrupt's wire JSON Schema into a validator or validates the resolved value against it, on the client or the server. Values pass through as-is; the application validates them (e.g. with z.fromJSONSchema on the client and its own check on the server). Validation of a tool's code-authored Standard Schema (approvalSchema / inputSchema) is unchanged. - remove the ajv-based json-schema-validator and the ajv / ajv-formats deps - generic items are always resolvable; canResolve no longer depends on the library being able to compile the wire schema - replace @noble/hashes with a small bundled SHA-256 (same sha256:<hex> wire shape), verified against known vectors; drop the dependency - update the ts-react-chat interrupt lab to validate the generic payload itself with zod, and fix the docs that claimed the library validates
…nctuary Replace the over-built interrupt lab (durable dead-code, error sanitizer, DI harness, debug fetch, capability panels, ~1.1k lines of tests) with a lean, button-driven "Willowbrook Sanctuary" playground: one tool per scenario, wired to chat()/useChat, each triggered by a preset message. Covers server + client tools for approval-only, shared approvalSchema, branch (approve/reject) schemas, and edited args; a generic (app-emitted) interrupt; and batched approvals resolved per-item or via the root resolveInterrupts. Fixes found by clicking through every scenario: - don't force provider tool_choice on a continuation (resume); forcing it made the model re-call the approved tool instead of answering (empty reply). - key the generic interrupt id off the request runId (ctx.runId), not the provider chunk.runId, so the client's parentRunId correlates on resume (the mismatch surfaced as unknown-interrupt). - echo edited args in assignEnclosure output so the continuation reflects them. Add a regression test (api.interrupts.test.ts) covering both fixes, and give the UI a cozier theme with per-card animal avatars.
A denied approval writes a final tool result into history, so the tool call reads as completed and dropped out of the reconstructed pending batch, while the resume batch still references its `approval_` id. That failed the resume as `unknown-interrupt` (every deny broke). Ephemeral reconstruction now recovers `approval_`-referenced calls from history the same way it already recovers finalized `client_tool_` calls. - add a regression in chat.test.ts: resume a denial whose call already has a result in history resolves without RUN_ERROR and skips execution. - fix the sibling batch-validation test to use a Standard Schema input; the earlier ajv removal means raw JSON Schema args are no longer library- validated, so the atomic-batch check now exercises the Standard Schema path.
- swap the emoji card avatars for real animal photos (loremflickr, keyed by species) with an emoji fallback if the image can't load - render tool calls and results in the transcript (name, input args including edits, output, and denial reasons) so it's clear what ran under the hood - add a "Your decisions" log that captures exactly what was submitted, so approve payloads/notes are visible even though they are decision metadata the tool never receives
Co-authored-by: Cursor <cursoragent@cursor.com>
jherr
approved these changes
Jul 21, 2026
tombeckenham
self-requested a review
July 22, 2026 06:56
tombeckenham
previously requested changes
Jul 22, 2026
tombeckenham
left a comment
Contributor
There was a problem hiding this comment.
Requesting changes until we resolve what @tannerlinsley raised regarding how interupts interact with durable workflows
…aul-slim # Conflicts: # pnpm-lock.yaml
tombeckenham
self-requested a review
July 22, 2026 09:11
test(interrupts): port wildlife scenarios into the e2e harness Add a deterministic interrupt playground (route /interrupts-test) that mirrors the ts-react-chat wildlife example, plus 31 aimock-backed specs covering every AG-UI interrupt shape across allow / deny / cancel via both per-item and root batch resolution: - server + client tool approvals: boolean, shared payload, branch payload, edited args - a generic (non-tool) interrupt (resolve-with-payload and cancel) - batch sets: root approve-all / deny-all / cancel-all, per-item, and the mixed resolver form Server tool results are asserted via the approval-responded state (they are not surfaced as client message parts); server edited-args are verified through an emitCustomEvent echo. A shared approvalSchema requires a payload on the deny decision too, so those scenarios carry a denyPayload. The ts-react-chat example is unchanged. @
An AG-UI `Interrupt` is a shared envelope. A workflow engine's durable approval, or another agent framework's pause, can arrive on the same stream. What makes a pause resumable through `chat()` is the binding this package attaches to the interrupt's metadata — nothing else. The client did not enforce that. `hydrateInterrupt` synthesised a binding for any descriptor that lacked one, so a foreign interrupt rendered as a resolvable generic interrupt whose answer was submitted against a run with nothing pending — failing as `unknown-interrupt` only after the user had filled in the form. That is the silent translation between approval models the orchestration RFC warns about. - Interrupts carrying no binding we understand surface as `kind: 'unbound'`, `canResolve: false`, with no resolver. Pre-binding TanStack descriptors keep their legacy metadata route. Unbound items are excluded from the batch-completeness gate so they cannot deadlock the interrupts that are ours. - The binding carries a wire version. Readers reject a version they do not recognise instead of duck-typing its fields; a binding written before the field existed still reads. This is what lets a future workflow-owned binding be rejected cleanly rather than mis-read. - Export `INTERRUPT_BINDING_METADATA_KEY`, `withInterruptBinding()` and `readInterruptBinding()` so other producers attach a binding through a supported API rather than copying the metadata key, which was a duplicated private const in three files. - Classify off the binding alone. `Interrupt.reason` is free-form AG-UI text another producer can also spell `tool_call`, so it is a display hint only and never decides ownership. The emitted values are unchanged. - Enumerate the interrupt export surface instead of `export *`. REVERSES A DELIBERATE DECISION — please review: 2d26b39 kept `ConnectionAdapter.loadInterruptState` plus `InterruptRecoveryStateV1` / `InterruptRecoveryQuery` as "a dormant extension seam for a future persistence layer", after stripping the recovery state machine that used them. This commit removes them, along with the `persistence-required` / `atomic-commit-unsupported` / `recovery-unavailable` batch codes. Reasoning: they describe a persisted, AI-domain interrupt object resumed through an AI-owned recovery endpoint — which is option 1 of the three the orchestration RFC (#979) has yet to choose between. Publishing them as stable types pre-decides that. Nothing consumes the seam today, and `origin/feat/persistence` (#785) has zero references to any of the three. Easy to restore if the RFC lands on option 1; hard to unpublish if it does not. Happy to put them back if the seam is wanted. Also fixes two pre-existing repo failures the gate surfaced: unresolved relative tool imports in two interrupt doc snippets (kiira), and knip flagging the type-only `toolApprovalCapability` brand symbol.
…eat/interrupts-overhaul-slim # Conflicts: # testing/e2e/src/routeTree.gen.ts
…options Replace the two-overload resolveInterrupt signature with a single generic call signature discriminated on `approved`. Overloads broke editor autocomplete: a half-typed options literal satisfies neither overload, so TS resolved no signature and offered no contextual completions. A generic `TApproved extends boolean` is inferred from the first argument and selects the matching branch for the rest params, so `payload`/`editedArgs` and the correct approve/reject schema fields complete per-branch while the wrong branch's fields are still rejected.
# Conflicts: # pnpm-lock.yaml
…eat/interrupts-overhaul-slim
…eat/interrupts-overhaul-slim
…wrong-branch payload The generic resolveInterrupt signature now reports the wrong-branch payload error precisely on the offending inner payload property rather than as a blanket 'no overload matches' on the whole call. Move the @ts-expect-error directive above that inner line in the framework type tests so it still suppresses the (now more precise) error.
AlemTuzlak
dismissed
tombeckenham’s stale review
July 22, 2026 11:21
Dismissing to unblock merge at repo owner's explicit request; autocomplete/type fixes landed and CI is green.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Extracts the AG-UI interrupts overhaul (from #935, which was stacked on the persistence PR #785) into its own PR targeting
main, excluding the persistence story. Interrupts run fully ephemeral here: resume reconstructs pending tool calls from the client's message history in a fresh child run. A follow-up stacked PR will add durable persistence back on top.What's in scope
RUN_FINISHEDwithoutcome.type === 'interrupt', interrupt descriptors/bindings, ephemeral resume viaparentRunId+resumebatch.interruptManager(hydrate / resolve / batch submit),useChatintegration across frameworks.ts-react-chatinterrupt-lab example for manual testing.Explicitly out of scope (deferred to the stacked persistence PR)
@tanstack/ai-persistence,-drizzle,-prisma,-cloudflare.joinRun, server-side interrupt state fetchers).Notable fixes
parentRunIdso ephemeral resume correlates the interrupted run to its continuation child run.MESSAGES_SNAPSHOTreset the live maps, so the approval UI state still updates.interrupts-v2) e2e routes/fixtures/specs and persistence-era recovery tests; these return with persistence.Testing
tool-approval.spec.tse2e green across all providers (approve continuation, denial, issue Tool name is undefined in messages after executing a tool that needs approval #532 follow-up).🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
/interruptsUI flow.Documentation